現在讓我們對元件進行單元測試吧
單元測試的原則為「把待測物當成黑盒子,專注於測試公開介面」,也就是說我們只會針對元件的 template、props、event、對外公開的 method 與屬性,不會測試元件內部的私有屬性和邏輯。
這是因為即使元件內部程式隨著時間變更,只要公開介面維持一致,都能保證測試過關,才不會讓測試本身變得過於脆弱、難以維護。
推薦大家看看這個很棒的演講,還有 Vue Test Utils 的文件。
讓我們開始寫測試案例吧!( ´ ▽ ` )ノ
第一步讓我們安裝測試工具。
npm i -D @vue/test-utils @vitest/ui
接著新增第一個測試。
src\components\btn-naughty\btn-naughty.spec.ts
import { mount } from '@vue/test-utils';
import { test, expect } from 'vitest';
import BtnNaughty from './btn-naughty.vue';
test('第一個測試', () => {
const wrapper = mount(BtnNaughty);
expect(wrapper).toBeDefined();
})
現在讓我們使用 vitest 執行測試,新增測試用的腳本。
package.json
{
...
"scripts": {
...
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
...
}
執行命令。
npm run test:ui
沒意外的話會開啟網頁,呈現以下畫面。
恭喜我們成功執行第一個測試了!ヾ(◍'౪`◍)ノ゙
現在讓我們依照元件的公開介面,依序新增各個測試案例吧。
src\components\btn-naughty\btn-naughty.spec.ts
...
test('設定 label', async () => {
const wrapper = mount(BtnNaughty);
const label = '很長很長的 label'
expect(wrapper.text()).not.toBe(label);
await wrapper.setProps({ label });
expect(wrapper.text()).toBe(label);
})
鱈魚:「然後就會發現測試完美的失敗啦!◝(≧∀≦)◟」
路人:「是在驕傲個甚麼鬼?Σ(ˊДˋ;)」
可以在終端機看到詳細錯誤訊息。
FAIL src/components/btn-naughty/btn-naughty.spec.ts > 設定 label
AssertionError: expected '我是按鈕' to be '很長很長的 label' // Object.is equality
- Expected
+ Received
- 很長很長的 label
+ 我是按鈕
❯ src/components/btn-naughty/btn-naughty.spec.ts:14:26
12|
13| wrapper.setProps({ label });
14| expect(wrapper.text()).toBe(label);
| ^
15| })
16|
這是因為我們的元件根本沒有實作顯示 label 功能,讓我們修正一下這個 Bug 吧。(´,,•ω•,,)
src\components\btn-naughty\btn-naughty.vue
<template>
<!-- 容器 -->
<div class="relative">
...
<!-- 按鈕容器 -->
<div ... >
<slot v-bind="attrs">
<button class="btn">
{{ props.label }}
</button>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
...
const props = withDefaults(defineProps<Props>(), {
label: '我是按鈕',
...
});
...
</script>
...
按下儲存的那一刻,會發現 vitest 已經執行完成了,這次完美通過了!(/≧▽≦)/
RERUN src/components/btn-naughty/btn-naughty.vue x17
✓ src/components/btn-naughty/btn-naughty.spec.ts (1)
✓ 設定 label
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 22:35:38
Duration 225ms
追加一些公開參數讓測試更方便進行。
src\components\btn-naughty\btn-naughty.vue
...
<script setup lang="ts">
...
// #region Methods
defineExpose({
/** 按鈕目前偏移量 */
offset: carrierOffset,
});
// #endregion Methods
</script>
...
現在讓我們追加更多的測試案例吧。ԅ(´∀` ԅ)
src\components\btn-naughty\btn-naughty.spec.ts
...
test('設定 zIndex', async () => {
const zIndex = 9999;
const wrapper = mount(BtnNaughty, {
props: { zIndex }
});
const carrierEl = wrapper.find('.carrier').element;
if (!(carrierEl instanceof HTMLElement)) {
throw new Error('carrierEl 不是 HTMLElement');
}
expect(carrierEl.style.zIndex).toBe(zIndex.toString());
})
test('設定 maxDistanceMultiple', async () => {
const wrapper = mount(BtnNaughty, {
props: { maxDistanceMultiple: 1, }
});
// 由於最大距離是 1,所以觸發兩次後一定會超出範圍,導致返回原點
await wrapper.find('button').trigger('click');
await wrapper.find('button').trigger('click');
expect(wrapper.vm.offset.x).toBe(0);
expect(wrapper.vm.offset.y).toBe(0);
})
test('disabled 後,觸發 click 會移動', async () => {
const wrapper = mount(BtnNaughty);
await wrapper.find('button').trigger('click');
// 未 disabled 時,應該有 click 事件
expect(wrapper.emitted()).toHaveProperty('click');
expect(wrapper.vm.offset.x).toBe(0);
expect(wrapper.vm.offset.y).toBe(0);
await wrapper.setProps({ disabled: true });
await wrapper.find('button').trigger('click');
// disabled 時,應該有 run 事件
expect(wrapper.emitted()).toHaveProperty('run');
// 而且會產生偏移
expect(wrapper.vm.offset.x).not.toBe(0);
expect(wrapper.vm.offset.y).not.toBe(0);
})
test('default slot 可修改按鈕 HTML 內容', async () => {
const wrapper = mount(BtnNaughty, {
slots: {
default: '<span class="btn">按我</span>',
}
});
// 預設的 button 不應該存在
expect(wrapper.find('button').exists()).toBe(false);
const target = wrapper.find('span');
expect(target.exists()).toBe(true);
expect(target.classes()).includes('btn');
})
test('rubbing slot 可修改拓印 HTML 內容', async () => {
const wrapper = mount(BtnNaughty, {
slots: {
rubbing: '<span class="rubbing">拓印</span>',
}
});
// 預設的 button 應該存在
expect(wrapper.find('button').exists()).toBe(true);
const target = wrapper.find('span');
expect(target.exists()).toBe(true);
expect(target.classes()).includes('rubbing');
})
順利通過!✧*。٩(ˊᗜˋ*)و✧*。
RERUN src/components/btn-naughty/btn-naughty.spec.ts x71
✓ src/components/btn-naughty/btn-naughty.spec.ts (6)
✓ 設定 label
✓ 設定 zIndex
✓ 設定 maxDistanceMultiple
✓ disabled 後,觸發 click 會移動
✓ default slot 可修改按鈕 HTML 內容
✓ rubbing slot 可修改拓印 HTML 內容
Test Files 1 passed (1)
Tests 6 passed (6)
Start at 00:28:58
Duration 283ms
不過這不代表以上測試已經覆蓋了所有情境,大家還可以想想看有甚麼測試案例,說不定還會發現隱藏的 Bug 喔。( ´ ▽ ` )ノ
以上程式碼已同步至 GitLab,大家可以前往下載: